F# hot reload: Edit-and-Continue delta emission behind --test:HotReloadDeltas#19941
F# hot reload: Edit-and-Continue delta emission behind --test:HotReloadDeltas#19941NatElkins wants to merge 385 commits into
Conversation
✅ No release notes required |
|
Looks like it requires rebase/conficts fix |
|
Hi, I gave a try to hot-reload demo. I followed steps 1-5 and failed. What am I doing wrong? First of all step 4 says:
Missing `F# hot reload session prestarted`After that I've tried to modify TLDR: not worksInfo:
dotnet --info |
|
After spending one or two hours debugging this, I found why this cannot work on Windows. The issue is that on the Windows, when the program is running, the program file will be locked. As a result, the "dotnet build" executed by the I finally have managed to make it work, done some simple test on it, and found some problems:
type Greeter() =
let mutable count = 0
member _.Message() =
count <- count + 1 // Modifying this number is OK
task {
do! Task.Delay 2000 // Modifying this number does not take effect until restart
}
|> _.GetAwaiter().GetResult()
sprintf "hello (count: %d)" count // Modifying this string is OK
let greeter = Greeter()
while true do
printfn "%s" (greeter.Message()) // Modifying this string doesn't take effect, until restart the program
System.Threading.Thread.Sleep(1000) // Changing this 1000 to 2000 doesn't take effect, until restart the program
member _.Message() =
count <- count + 1
task {
do! Task.Delay 2000
do! Task.Delay 2000 // add this line
}
member _.Message() =
count <- count + 1 // remove this line
task {
do! Task.Delay 2000
} |
The per-edit dotnet build refreshed the bin output the running process has loaded. Windows locks that file against writes while the app runs, so the build failed on every edit and the change fell back to a full restart instead of applying in place. Build the Compile target only (the obj intermediate assembly fsc writes) and point the hot reload session's baseline and emit reads at that intermediate assembly via FSharpProjectInfo.IntermediateAssemblyPath. The running process never loads that file, so nothing is locked, and the bin output is left at generation 0 until the next real restart. macOS and Linux already tolerated overwriting a mapped file; this lines all three platforms up on the same path. Reported on dotnet/fsharp#19941.
|
@ijklam got it right. The bridge ran a full The fix builds just the Compile target instead, so fsc refreshes the intermediate assembly under It's on @WizMe-M the missing On the other observations: editing the top level EDIT: Correcting myself on the struck-out line. @ijklam is right, C# hot reload does support adding an |
|
I think "adding or removing lines before a
By the way, C# hot reload supports this already. |
… identity The resumable-code/trait shape digest rendered builder-call type instantiations through TType.ToString() (tyToString), whose depth-limited LimitedToString(4) collapses a deeply-solved typar to the literal "True". That made the digest non-injective: a `task` whose return type is `int` both sides could render Bind<int,int,int> before an edit and Bind<int,True,True> after, producing a false StateMachineShapeChange (FSHRDL013) rude edit. Render the digest through tryTypeIdentityFromTType -- the same injective runtime-identity encoder the capture path already stores -- threading the method's typar->ordinal map into collectLoweredShapeInfo and traitConstraintShapeDigest, with a structured formatter and a display-string fallback only for types the encoder cannot represent. Update the architecture guard for the new traitConstraintShapeDigest signature. Addresses review feedback on dotnet#19941.
FSharpEditAndContinueLanguageService.UpdateActiveStatements had no callers: the live path is SetActiveStatements (FSharpHotReloadSession.SetActiveStatements -> SetSessionActiveStatements -> editAndContinueService.SetActiveStatements). Remove the dead member and its now-orphaned HotReloadSessionStore.UpdateActiveStatements backing, whose only caller was the dead forwarder. Addresses review feedback on dotnet#19941.
…error channel Rude-edit reasons were flattened to a single string at the point of failure (UnsupportedEdit of string), discarding the per-edit Id, Severity and symbol before they reached the public API and the dotnet-watch bridge. Carry them structurally instead: * RudeEditDiagnostic gains a Severity (FSharpDiagnosticSeverity). Every kind is Error today; severityOf is the single place to introduce a non-blocking Warning later (e.g. a "might not take effect" edit). * HotReloadError.UnsupportedEdit and the public FSharpHotReloadError.UnsupportedEdit now carry a list of structured diagnostics. A new public FSharpHotReloadRudeEdit record exposes Id, Severity, Message and SymbolName. * The active-statement, deleted-symbol, mapping-error and emit-exception paths wrap their ad-hoc reason via RudeEditDiagnostics.unsupported so every error still carries an id. The id namespace is unchanged and still owned solely by RudeEditDiagnostics.diagnosticId, so the channel itself is id-agnostic. Addresses review feedback on dotnet#19941.
…he swap point Document, at RudeEditDiagnostics.diagnosticId (the single place the rude-edit id namespace is decided), why F# keeps its own FSHRDL* codes rather than emitting Roslyn's ENC* codes, and note the closest ENC analogs. Kept as a separate commit from the channel work so aligning with the ENC codes later is an isolated change. Addresses review feedback on dotnet#19941.
…t-watch
The F# bridge collapsed the rude-edit reason to a record/DU ToString() dump and
logged it at Debug only, so an edit that forces a restart gave the user no reason.
Now that FSharpHotReloadError.UnsupportedEdit carries a structured rude-edit list
(Id + Severity + Message), read it via reflection (TryFormatRudeEdits) into a clean
"{Id}: {Message}" reason and report it as a warning instead of at Debug. The
extraction is defensive: any shape mismatch falls back to ToString(), so the bridge
stays correct against older FCS builds (where UnsupportedEdit still carries a string).
Pairs with the FCS-side structured diagnostics channel on dotnet/fsharp#19941.
Addresses review feedback on #1.
…17/hotreload-inprocess-compile
…esh/20260717/hot-reload-v2
|
@WizMe-M @ijklam I went back through this feedback while refreshing the stack. The Windows file-lock problem is fixed in the SDK PR. The F# bridge now builds only the Compile target and reads the intermediate assembly under obj, so it no longer tries to overwrite the loaded bin assembly. The await parity point is not being ignored either. F# task state machines are structs by default, so adding or removing a let! or do! changes their layout and still fails closed on the default path. This branch now has an experimental --test:HotReloadClassStateMachines path that emits reference-type state machines when explicitly enabled. Runtime ApplyUpdate tests cover adding and removing let!, adding one inside a loop, backgroundTask, and do!. That closes the Roslyn-style behavior behind the test flag, but I have not made it the default because changing the normal task state-machine representation needs a broader compatibility and performance decision. The refreshed umbrella head is 3c49b38. The full local verifier passes at that head: 472 service tests, 248 component tests with 2 intentional manual-host skips, both smoke modes, and direct multi-delta runtime apply. If the original Windows reproduction still fails with the refreshed SDK branch, please let me know what you see. |
This draft PR presents the complete F# hot reload implementation as a single branch against current main:
dotnet watch(with a companion dotnet-watch change, linked below) patches running F# processes in place via the standard EnC pipeline (MetadataUpdater.ApplyUpdate) — the same runtime contract C# uses. Unsupported edits degrade to the rebuild-and-restart flow watch uses today, so a limited scope still behaves as a complete feature.It is opened as a single draft per discussion with @T-Gro: one branch to review, build, and launch testing from. A decomposition into independently shippable PRs is laid out below and can begin whenever review reaches that stage. Earlier discussion: #11636.
Try it (30–45 min, two clones, copy-paste steps)
docs/hot-reload-quickstart.md walks from
git cloneto editing a running F# app — including adding aList.map (fun s -> s.ToUpper())to a live method and watching it apply in place, state preserved. Short version: build this branch, build NatElkins/sdkfsharp-hotreload-watch-v2(a complete .NET CLI with an F#-awaredotnet watch), copy the freshly builtFSharp.Compiler.Service.dllinto the SDK layout, add<OtherFlags>$(OtherFlags) --test:HotReloadDeltas</OtherFlags>to a console app, anddotnet watch run.What works
async, resume-point-stabletask, and generics--test:HotReloadClassStateMachines): adding or removing alet!/do!intaskandbackgroundTask, which emits class-form (reference-type) state machines so the change is anAddInstanceFieldToExistingTypeplus a method update, matching C#.taskSeqand other resumable CEs share the same lowering path. Off by default; flag-off codegen is byte-identical.[<CLIEvent>]eventsinline-annotation change) degrade to the standard rebuild-and-restart flow with a precise diagnostic. Adding or removing a mid-sequencelet!/do!is rude under the default struct state machines, but becomes a supported edit (anAddInstanceFieldToExistingTypeplus a method update, matching C#) under--test:HotReloadClassStateMachinesIsolation and disabling
The feature is designed so that a compile without the flag is indistinguishable from main, and so that the whole feature can be turned off at one point if something goes wrong:
--test:HotReloadDeltas(requires--debug+, incompatible with--optimize+), with class-form resumable state machines a further opt-in behind--test:HotReloadClassStateMachines. Both are off by default; no MSBuild property or SDK behavior changes in this repo.FSharpProjectSnapshot.fsis byte-identical to main (tracked-input staleness lives in the hot reload session layer instead).ICompilerEmitHookinterface with a no-op default. Guard scripts undertests/scripts/(run by the verification gate) enforce that only the intended files consume the hook and that theIlxGenname-generation path and fsi surface stay on their pinned shapes.DOTNET_WATCH_FSHARP_HOTRELOAD=0) that restores stock restart-on-edit behavior.Architecture
FSharpChecker.CreateHotReloadSessionreturns aFSharpHotReloadSessionholding per-project committed snapshots + emit baselines — theDebuggingSession/CommittedSolutionshape from Roslyn, built onFSharpProjectSnapshot(the snapshot contract, not the experimental workspace surface), so it composes with the FSharpWorkspace direction without depending on it. Solution-wide commit/discard semantics, runtime-capability updates, active-statement intake.AddMethodToExistingType,NewTypeDefinition,GenericUpdateMethod, …). Anything unclassifiable fails closed.EmitDifferencereference deltas, with mdv, and against CoreCLRApplyUpdatein runtime tests.Design documentation (rendered, on this branch)
FSharpHotReloadSession, per-project committed snapshots + baselines onFSharpProjectSnapshot, the FSharpWorkspace relationship, determinism pinsEmitDifferencereference templates (EncLog/EncMap shapes per edit kind) and the F# emission matrix incl. every intentional fail-closed caseEditAndContinueCapabilitiesparity) and per-capability gatingScale and review shape
~118 commits, 159 files, ~60k insertions, of which ~34k are tests and ~2.4k docs. The src/ changes are predominantly new self-contained modules (
IlxDeltaEmitter.fs,TypedTreeDiff.fs,HotReloadBaseline.fs, the AbstractIL EnC readers, the delta writer stack). Pre-existing files carry hook callouts plus one behavior-neutral refactor (ilwrite.fsMetadataTablerecord→class, exposing a baseline-row access seam for the delta writer); total deletions across the branch are 241 lines.Proposed path to merging in pieces
The fail-closed design means scope can grow capability by capability — each unsupported case is already a rude edit with a diagnostic, so every intermediate state is a complete, working feature. The natural sequence:
IlxGen/name-generation paths — reviewed on its own)Evidence
ApplyUpdate+ invocation, multi-generation chains, cross-process (disk-started) sessions#Stringsheap layout #19732, Determinism: closure type names differ between --parallelcompilation- and --parallelcompilation+ #19928 (open; flag-on compiles are insulated),$(ParallelCompilation)never reaches the Fsc task — targets pass the item@(ParallelCompilation)instead of the property #19935 (why the pin is compiler-level)./tests/scripts/hot-reload-verify.shKnown limitations / future work
Companion PRs
Refresh status (2026-07-17)
Refresh status (2026-07-22)
Refresh status (2026-07-24)
2c8d3a7081, with dotnet/fsharp main1dc395ad34and the exact refreshed focused stack merged in dependency order.